//    Copyright (c) 2009 Elwyn Malethan
//
//    This file is part of SeemoreJ.
//
//    SeemoreJ is free software: you can redistribute it and/or modify
//    it under the terms of the GNU General Public License as published by
//    the Free Software Foundation, either version 3 of the License, or
//    (at your option) any later version.
//
//    SeemoreJ is distributed in the hope that it will be useful,
//    but WITHOUT ANY WARRANTY; without even the implied warranty of
//    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//    GNU General Public License for more details.
//
//    You should have received a copy of the GNU General Public License
//    along with SeemoreJ.  If not, see <http://www.gnu.org/licenses/>.

package com.malethan.seemorej.hibernate.crud;

import com.malethan.seemorej.Accepts;
import static com.malethan.seemorej.Flash.NOTICE;
import static com.malethan.seemorej.Flash.SUCCESS;
import com.malethan.seemorej.HttpMethod;
import static com.malethan.seemorej.SeemoreJ.*;
import com.malethan.seemorej.crud.CrudException;
import com.malethan.seemorej.crud.StringUtils;
import com.malethan.seemorej.dao.GenericDao;
import com.malethan.seemorej.dao.ValidationMessage;
import com.malethan.seemorej.hibernate.HibernateUtil;
import com.malethan.seemorej.hibernate.dao.GenericDaoHibernate;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.hibernate.cfg.Configuration;
import org.hibernate.mapping.Property;
import org.hibernate.tool.hbm2x.ArtifactCollector;
import org.hibernate.tool.hbm2x.Cfg2HbmTool;
import org.hibernate.tool.hbm2x.Cfg2JavaTool;
import org.hibernate.tool.hbm2x.GenericExporter;
import org.hibernate.tool.hbm2x.pojo.POJOClass;

import javax.servlet.ServletException;
import java.io.File;
import java.io.IOException;
import java.io.Serializable;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.*;

/**
 * <p>Provides scaffolding for standard CRUD actions</p>
 * TODO: Be more flexible with the name of the id field
 */
public abstract class CrudControllerHibernate<T, PK extends Serializable> implements com.malethan.seemorej.crud.CrudController<T> {
    //----------------------------------------------------------------------- Static Properties and Constants

    private static final Log log = LogFactory.getLog(CrudControllerHibernate.class);

    //----------------------------------------------------------------------- Static Methods
    //----------------------------------------------------------------------- Instance Properties

    protected Class<T> modelClass;
    protected Class<PK> pkClass;
    protected GenericDao<T, PK> dao;
    protected StringUtils util;
    protected String modelNameLower;
    protected Map<String,String> associationLabels;

    //----------------------------------------------------------------------- Constructors

    protected CrudControllerHibernate(Class<T> modelClass, Class<PK> pkClass) {
        this.modelClass = modelClass;
        this.pkClass = pkClass;
        this.dao = new GenericDaoHibernate(modelClass); //TODO Use factory

        this.util = new StringUtils();
        this.modelNameLower = util.getLowerCaseFirstLetter(modelClass.getSimpleName());

        this.associationLabels = new HashMap<String, String>();
    }

    //----------------------------------------------------------------------- Filters
    //----------------------------------------------------------------------- Actions

    /**
     * Shows list of all the records for T, retreived using GenericDao
     * 
     * @throws IOException
     */
    public void list() throws Exception {
        List<T> models = dao.find();
        request().setAttribute(util.getPluralForWord(modelNameLower), models);

        renderCrudView("list");
    }

    public void show() throws Exception {
        T model = loadModel(request().getParameter("id"));

        request().setAttribute(modelNameLower, model);
        renderCrudView("show");
    }

    public void newAction() throws Exception {
        renderCrudView("new");
    }

    public void edit() throws Exception {
        T model = loadModel(request().getParameter("id"));

        request().setAttribute(modelNameLower, model);
        renderCrudView("edit");
    }

    @Accepts(method = HttpMethod.POST)
    public void create() throws Exception {
        T model = modelClass.getConstructor().newInstance();
        updateModel(model, request().getParameterMap());

        if (!validateAndSave(model)) {
            request().setAttribute(modelNameLower, model);
            renderCrudView("new");
        } else {
            flash(SUCCESS, modelClass.getSimpleName() + " created");
            list();
            afterSuccessfulCreate();
        }
    }

    /**
     * Stub, to be overridden by subclass
     * @throws Exception if something bad happens
     */
    protected void afterSuccessfulCreate() throws Exception {

    }

    @Accepts(method = HttpMethod.POST)
    public void update() throws Exception {
        T model = loadModel(request().getParameter(modelNameLower + ".id"));

        updateModel(model, request().getParameterMap());

        if (validateAndSave(model)) {
            request().setAttribute(modelNameLower, model);
            flash(SUCCESS, util.humaniseCamelCase(modelClass.getSimpleName()) + " updated");
            renderCrudView("show");
            afterSuccessfulUpdate();
        } else {
            request().setAttribute(modelNameLower, model);
            renderCrudView("edit");
        }
    }

    /**
     * Stub, to be overridden by subclass
     * @throws Exception if something bad happens
     */
    protected void afterSuccessfulUpdate() throws Exception {

    }

    @Accepts(method = HttpMethod.POST)
    public void delete() throws Exception {
        T model = loadModel(request().getParameter("id"));
        if (model != null) {
            dao.remove(model);
            flash(NOTICE, util.humaniseCamelCase(modelClass.getSimpleName()) + " deleted");
        }

        list();
    }

    //----------------------------------------------------------------------- Getters and Setters
    //----------------------------------------------------------------------- Instance Methods

    protected boolean validateAndSave(T model) {
        final ValidationMessage[] errors = dao.validate(model);
        if (errors.length == 0) {
            dao.save(model);
            return true;
        } else {
            request().setAttribute("VALIDATION_MESSAGES", new HashMap<String, ValidationMessage[]>() {{put(modelNameLower, errors);}});
            return false;
        }
    }

    /**
     * Loads a model from the database with the given identifier using Session#load(id)
     * 
     * @param idParam
     * @return
     * @throws Exception
     */
    protected T loadModel(String idParam) throws Exception {
        try {
            //Attempt to use value of
            PK pk;
            Method vo = pkClass.getDeclaredMethod("valueOf", String.class);
            pk = (PK) vo.invoke(null, idParam);
            return dao.load(pk);
        } catch (NoSuchMethodException e) {
            throw new CrudException("The PK class does not have a valueOf method", e);
        } catch (InvocationTargetException e) {
            throw new CrudException("Failed to convert a string to the PK class", e.getCause());
        }
    }

    /**
     * Assigns values from the request parameters to the model
     * TODO: Refactor this so that it is testable and not so messy
     * 
     * @param model
     * @param params
     */
    protected void updateModel(T model, Map<String, String[]> params) {
        Cfg2JavaTool c2j = new Cfg2JavaTool();
        Cfg2HbmTool c2h = new Cfg2HbmTool();
        Iterator<POJOClass> iterator = c2j.getPOJOIterator(HibernateUtil.getConfiguration().getClassMappings());
        POJOClass pojo = null;
        while(iterator.hasNext()) {
            pojo = iterator.next();
            if(modelClass.getSimpleName().equals(pojo.getShortName())) {
                break;
            }
        }
        if(pojo != null) {
            for (Iterator propIt = pojo.getAllPropertiesIterator(); propIt.hasNext();) {
                Property field =  (Property) propIt.next();
                if (!field.equals(pojo.getIdentifierProperty()) && !c2h.isCollection(field) && !c2j.isComponent(field)) {
                    String fieldName = field.getName();
                    String typeName = field.getValue().getType().getName();
                    String setterName = "set" + util.getUpperCaseFirstLetter(fieldName);
                    String paramName;

                    if(c2h.isManyToOne(field)) {
                        paramName = modelNameLower + "." + fieldName + "_id";
                    } else {
                        paramName = modelNameLower + "." + fieldName;
                    }

                    Class valueType = null;
                    Object value = null;
                    if (params.containsKey(paramName) && params.get(paramName).length > 0) {
                        try {
                            log.debug("Looking to set value of type " + typeName);
                            if (typeName.matches("(java\\.lang\\.String|text|string)")) {
                                valueType = String.class;
                                value = params.get(paramName)[0];
                            } else if (typeName.matches("(java\\.util\\.Date|timestamp)")) {
                                //TODO We need to know what format the date is in.
    //                            valueType = Date.class;
                                log.warn("Date's represented in one param is not currently supported." +
                                        " TODO: We need to know what format the date is in.");
                            } else if (typeName.equals("java.lang.Integer")) {
                                valueType = Integer.class;
                                value = Integer.valueOf(params.get(paramName)[0]);
                            } else if (typeName.equals("int") || typeName.equals("integer")) {
                                valueType = int.class;
                                value = Integer.valueOf(params.get(paramName)[0]);
                            } else if (typeName.equals("java.lang.Float")) {
                                valueType = Float.class;
                                value = Float.valueOf(params.get(paramName)[0]);
                            } else if (typeName.equals("float")) {
                                valueType = float.class;
                                value = Float.valueOf(params.get(paramName)[0]);
                            } else if (typeName.equals("java.lang.Boolean")) {
                                valueType = Boolean.class;
                                value = Boolean.valueOf(params.get(paramName)[0]);
                            } else if (typeName.matches("boolean")) {
                                valueType = boolean.class;
                                value = Boolean.valueOf(params.get(paramName)[0]);
                            } else {
                                log.warn("Couldn't decide what to do with type " + typeName);
                            }
                        } catch (NumberFormatException e) {
                            log.warn("Field " + paramName + " of type " + typeName + " was not a number", e);
                            value = 0;
                        }
                        if (c2h.isManyToOne(field)) {
                            try {
                                Long id = Long.valueOf(params.get(paramName)[0]);
                                valueType = Class.forName(typeName);
                                value = new GenericDaoHibernate(valueType).load(id);
                            } catch (ClassNotFoundException e) {
                                throw new RuntimeException(e);
                            }
                        }
                    } else if (typeName.matches("(date|time|timestamp)")) {
                        //check for the many time fields
                        Map<Integer, String> dateFields = new HashMap<Integer, String>();
                        if (typeName.matches("date|timestamp")) {
                            dateFields.put(Calendar.YEAR, "_year");
                            dateFields.put(Calendar.MONTH, "_month");
                            dateFields.put(Calendar.DATE, "_day");
                        }
                        if (typeName.matches("time|timestamp")) {
                            dateFields.put(Calendar.HOUR_OF_DAY, "_hour");
                            dateFields.put(Calendar.MINUTE, "_minute");
                            dateFields.put(Calendar.SECOND, "_second");
                        }

                        GregorianCalendar gc = new GregorianCalendar();
                        boolean haveSomeFields = false;
                        for (Integer dField : dateFields.keySet()) {
                            String fieldParamName = paramName + dateFields.get(dField);
                            if (params.containsKey(fieldParamName) && params.get(fieldParamName).length > 0) {
                                haveSomeFields = true;
                                Integer dFieldValue = Integer.valueOf(params.get(fieldParamName)[0]);
                                if(dField == Calendar.MONTH) {
                                    dFieldValue -= 1;
                                }
                                log.debug("Setting " + dateFields.get(dField) + " with " + dFieldValue);
                                gc.set(dField, dFieldValue);
                            }
                        }
                        if (haveSomeFields) {
                            valueType = Date.class;
                            value = gc.getTime();
                        }
                    }
                    try {
                        if(valueType != null) {
                            modelClass.getMethod(setterName, valueType).invoke(model, value);
                        }
                    } catch (IllegalAccessException e) {
                        log.error(e);
                    } catch (InvocationTargetException e) {
                        log.error(e);
                    } catch (NoSuchMethodException e) {
                        log.error(e);
                    }
                }
            }
        } else {
            log.error("Couldn't find " + modelClass.getSimpleName() + " in persistent classes");
        }
    }

    protected void renderCrudView(String actionName) throws IOException, ServletException {
        String overrideView = "/WEB-INF/app/" + request().getParameter(CONTROLLER) + "/"
                + actionName + "." + request().getParameter(FORMAT) + ".jsp";
        String realPath = request().getRealPath("/");
        String overrideViewLocation = realPath + overrideView;
        log.debug("Looking for possible override view in " + overrideViewLocation);
        if(new File(overrideViewLocation).exists()) {
            log.debug("Using override JSP '" + overrideView + "' for CRUD view " + actionName);
            renderJsp(overrideView);
        } else {
            //Generate the view
            String pattern = "/WEB-INF/crud/" + request().getParameter(CONTROLLER) + "/{class-name}_" + actionName + ".jsp";
            String template = "com/malethan/seemorej/hibernate/crud/views/" + actionName + "-view.ftl";

            //TODO: Not sure I want to expose the hibernate configuration like this...
            Configuration configuration = HibernateUtil.getConfiguration();
            GenericExporter exporter = new GenericExporter() {

                @Override
                protected void exportPOJO(Map map, POJOClass element) {
                    String entityName = modelClass.getSimpleName();
                    log.debug("Looking for entity named " + entityName);
                    if (element.getShortName().contains(entityName)) {
                        super.exportPOJO(map, element);
                    }
                }

//            @Override
//            protected String resolveFilename(POJOClass element) {
//                String filename = super.resolveFilename(element);
//                String packageLocation = StringHelper.replace(getPackageNameForFile(element), ".", "/");
//                if (packageLocation.endsWith("model") && packageLocation.indexOf('/') > -1) {
//                    packageLocation = packageLocation.substring(0, packageLocation.lastIndexOf('/'));
//                }
//                filename = StringHelper.replace(filename, "{basepkg-name}", packageLocation);
//                return filename;
//            }

            };
            exporter.setConfiguration(configuration);
            exporter.setOutputDirectory(new File(request().getRealPath("/")));
            exporter.setProperties(new Properties());
//        exporter.setTemplatePath(templatePaths);
            exporter.setTemplateName(template);
            exporter.setFilePattern(pattern);
            exporter.setArtifactCollector(new ArtifactCollector());

//        exporter.getProperties().put("data", new DataHelper());
            exporter.getProperties().put("util", util);
            exporter.getProperties().put("assoc", associationLabels);

            exporter.start();

            String viewFile = pattern.replace("{class-name}", modelClass.getSimpleName());

            renderJsp(viewFile);
        }
    }

    /**
     * Specifies the property on an associated model that should act as the label. We should be able to
     * determine the other information from the association itself 
     *
     * @param localProperty
     * @param foreignLabelProperty
     */
    protected void addAssociationLabelProperty(String localProperty, String foreignLabelProperty) {
        associationLabels.put(localProperty, foreignLabelProperty);
    }
}
